/**
 * Not type-checking this file because it's mostly vendor code.
 */

/*!
 * HTML Parser By John Resig (ejohn.org)
 * Modified by Juriy "kangax" Zaytsev
 * Original code by Erik Arvidsson, Mozilla Public License
 * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
 */

import {makeMap, no} from 'shared/util'
import {isNonPhrasingTag} from 'web/compiler/util'

// Regular Expressions for parsing tags and attributes
const singleAttrIdentifier = /([^\s"'<>/=]+)/ //解析属性名
const singleAttrAssign = /(?:=)/ //解析属性名与值之间的等号
//解析属性值
const singleAttrValues = [
  // attr value double quotes
  /"([^"]*)"+/.source, //双引号
  // attr value, single quotes
  /'([^']*)'+/.source, //单引号
  // attr value, no quotes
  /([^\s"'=<>`]+)/.source //没有引号
]
//解析一个完整的属性
const attribute = new RegExp(
  '^\\s*' + singleAttrIdentifier.source +
  '(?:\\s*(' + singleAttrAssign.source + ')' +
  '\\s*(?:' + singleAttrValues.join('|') + '))?'
)

// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = '[a-zA-Z_][\\w\\-\\.]*' //匹配一个合法的名字，第一个首字符必须为字母或者_，之后的字符可为字母、-或.
const qnameCapture = '((?:' + ncname + '\\:)?' + ncname + ')' //匹配标签名，注意标签名前可能存在命名空间
const startTagOpen = new RegExp('^<' + qnameCapture) //匹配开始标签
const startTagClose = /^\s*(\/?)>/ //匹配<hr/>中的/>或者<p>中的>，也就是匹配开始标签的结束符
const endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>') //匹配结束标签
const doctype = /^<!DOCTYPE [^>]+>/i //匹配文档类型标识
const comment = /^<!--/ //匹配注释的开始
const conditionalComment = /^<!\[/

let IS_REGEX_CAPTURING_BROKEN = false
'x'.replace(/x(.)?/g, function (m, g) {
  IS_REGEX_CAPTURING_BROKEN = g === ''
})

// Special Elements (can contain anything)
//对于这些元素，不会处理其内部的内容
export const isPlainTextElement = makeMap('script,style,textarea', true)
const reCache = {}

const decodingMap = {
  '&lt;': '<',
  '&gt;': '>',
  '&quot;': '"',
  '&amp;': '&',
  '&#10;': '\n'
}
const encodedAttr = /&(?:lt|gt|quot|amp);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10);/g

// #5992
const isIgnoreNewlineTag = makeMap('pre,textarea', true)
//忽略新行是干嘛？
//对于textarea和pre来说，在html处理时，会忽略这两个标签的第一个换行符，示例：
//document.write("<textarea cols='10' rows='10'>" + "\nhello\nbabe\n" + "</textarea>");
//创建的textarea中的文本的第一个换行符会被忽略。但是在vue实现中，会通过createTextNode的方式
//创建文本节点，并通过appendChild的方式添加子文本节点，通过这种方式创建的textarea的子文本不会忽略
//第一个换行符，这样就造成了与html的实现不一致，所以如果标签是textarea或pre，并且第一个字符为换行符，
//则在程序中忽略该换行符，保持与html表现的一致性。
const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n'

function decodeAttr(value, shouldDecodeNewlines) {
  const re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr
  return value.replace(re, match => decodingMap[match])
}

export function parseHTML(html, options) {
  //内部数据的格式为：{ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs }
  const stack = []
  //只有在非web构建时才为false
  const expectHTML = options.expectHTML
  //检查当前标签是否一元的标签，也就是说标签没有结束标签，并且在开始标签中
  //可以不存在/符号作为标签的结束
  const isUnaryTag = options.isUnaryTag || no
  //这些标签不可与自身嵌套使用，比如<td><td></td></td>是不对的，在vue解析时，会处理为<td></td><td></td></td></td>
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  //存放当前解析的html字符串的下标位置，是完整的待解析字符串的下标位置
  let index = 0
  //last用于存放剩余的html字符串
  //lastTag用于存放当前处理的元素的标签名
  let last, lastTag
  while (html) {
    last = html
    // Make sure we're not in a plaintext content element like script/style
    //lastTag不存在，或者存在，但是不是script、style或者textarea
    if (!lastTag || !isPlainTextElement(lastTag)) {
      if (shouldIgnoreFirstNewline(lastTag, html)) {
        advance(1)
      }
      //textEnd存放着纯文本的下标位置
      let textEnd = html.indexOf('<')
      if (textEnd === 0) { //不存在文本
        // Comment:
        if (comment.test(html)) {
          const commentEnd = html.indexOf('-->')

          if (commentEnd >= 0) {
            if (options.shouldKeepComment) {
              options.comment(html.substring(4, commentEnd))
            }
            advance(commentEnd + 3)
            continue
          }
        }

        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        // https://zh.wikipedia.org/wiki/%E6%9D%A1%E4%BB%B6%E6%B3%A8%E9%87%8A
        // 条件注释 (conditional comment) 是于HTML源码中被 Microsoft Internet Explorer 有条件解释的语句。
        // 条件注释可被用来向 Internet Explorer 提供及隐藏代码。
        if (conditionalComment.test(html)) {
          const conditionalEnd = html.indexOf(']>')

          if (conditionalEnd >= 0) {
            advance(conditionalEnd + 2)
            continue
          }
        }

        // Doctype:
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }

        // End tag:
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          //比如整个结束标签为'</div>'，
          //endTagMatch[0]为整个字符串'</div>'
          //endTagMatch[1]为'div'
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // Start tag:
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          handleStartTag(startTagMatch)
          continue
        }
      }

      //text存放着html字符串中的纯文本
      //rest存放着除去纯文本后剩下的html字符串
      let text, rest, next
      if (textEnd >= 0) {
        rest = html.slice(textEnd) //注意，rest字符串中，第一个字符为'<'
        //当前textEnd处的<可能就是一个单纯的文本，而不是标签的开始，
        //所以需要跳过该<，继续查找标签
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
          ) {
          // < in plain text, be forgiving and treat it as text
          //在while的判断中，已经保证rest不是结束标签、开始标签、注释或者conditionalComment，
          //这里的next指向可能存在的标签的开始位置
          next = rest.indexOf('<', 1)
          if (next < 0) break
          //textEnd存放着纯文本的下标位置
          textEnd += next
          rest = html.slice(textEnd)
        }
        text = html.substring(0, textEnd)
        advance(textEnd)
      }

      //如果未查找到<符号，则直接将剩余的html字符串作为纯文本对待
      if (textEnd < 0) {
        text = html
        html = ''
      }

      if (options.chars && text) {
        options.chars(text)
      }
    } else { //else说明lastTag存在，并且lastTag是script或style或textarea
      let endTagLength = 0
      const stackedTag = lastTag.toLowerCase()
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
      //示例：' adsfasdfa asdfasdf asdfasdf </script>'
      //all保存的是整个匹配结果
      //text是' adsfasdfa asdfasdf asdfasdf '
      //endTag为：'</script>'
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        //@todo 为什么下面要这么处理？
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!--([\s\S]*?)-->/g, '$1')
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        if (options.chars) {
          options.chars(text)
        }
        return ''
      })
      index += html.length - rest.length
      html = rest
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    //在本次循环过程中，程序对html没有做任何事情，
    //什么情况下会不做任何事情？这个还没有研究明白，如果出现类似问题再总结。
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`)
      }
      break
    }
  }

  // Clean up any remaining tags
  // 清空栈中剩余的所有未解析的元素
  parseEndTag()

  //该方法用于将字符串跳过n个字符，并截取剩余的字符串
  function advance(n) {
    index += n
    html = html.substring(n)
  }

  /**
   * 该方法用于解析开始标签，包括开始标签的标签名、
   * 标签中的属性等
   * @returns {{tagName: *, attrs: Array, start: number,unarySlash: *,end:number}}
   * tagName：标签名。
   * attrs数组：该标签中的属性的正则表达式的匹配结果。
   * start：当前标签的开始下标，该下标是相对于整个待解析的字符串。
   * unarySlash：如果当前标签为单标签，则该属性中存储着'/'字符串。否则为undefined
   * end：当前标签的结束下标，该下标是相对于整个待解析的字符串。
   */
  function parseStartTag() {
    const start = html.match(startTagOpen)
    //匹配开始标签的标签名
    if (start) {
      const match = {
        tagName: start[1], //start[1]中存储着标签名
        attrs: [],//该标签中的属性的正则表达式的匹配结果
        start: index //当前标签的开始下标
      }
      //start[0]中存储着完整的匹配
      advance(start[0].length)
      let end, attr
      //解析开始标签中的属性
      while (
        !(end = html.match(startTagClose)) //不是开始标签的结束符
        && (attr = html.match(attribute)) //解析属性
        ) {
        advance(attr[0].length)
        //注意attr存储着完整的匹配结果
        match.attrs.push(attr)
      }

      //end用于保存开始标签的结束符，但是标签也可能是
      //单标签，所以end可能的匹配结果为： >或 />
      if (end) {
        //unarySlash用于保存可能存在的/，也就是说，当前标签是单标签的
        match.unarySlash = end[1]
        advance(end[0].length)
        //当前元素的开始标签的结束下标，
        //或者整个元素的结束下标（如果当前元素是单标签元素）
        match.end = index
        return match
      }
    }
  }

  /**
   * match的内部结构为：
   * {{tagName: *, attrs: Array, start: number,unarySlash: *,end:number}}
   * tagName：标签名。
   * attrs数组：该标签中的属性的正则表达式的匹配结果。
   * start：当前标签的开始下标，该下标是相对于整个待解析的字符串。
   * unarySlash：如果当前标签为单标签，则该属性中存储着'/'字符串。否则为undefined
   * end：当前标签的结束下标，该下标是相对于整个待解析的字符串。
   */
  function handleStartTag(match) {
    //标签名，可能存在命名空间
    const tagName = match.tagName
    //如果当前标签为单标签，则unarySlash属性存储着'/'。
    const unarySlash = match.unarySlash

    if (expectHTML) {
      //p标签中只能放置文本，不可放置其他元素节点，如果p元素中出现其他
      //元素，则会直接关闭p元素
      if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
        parseEndTag(lastTag)
      }
      //标签不可与自身嵌套使用，比如<td><td></td></td>是不对的，在vue解析时，会处理为<td></td><td></td></td></td>
      if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
        parseEndTag(tagName)
      }
    }

    //unary用于标识当前标签为单标签（一元标签），也就是没有结束标签的元素
    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
      // @todo 什么意思？
      if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
        //正则表达式中，对于|操作符，有几个分支，并且分支有分组，就会在结果中出现一个占位的分组，
        //这里args的3, 4, 5分别为双引号、单引号、没有引号的捕获结果
        if (args[3] === '') {
          delete args[3]
        }
        if (args[4] === '') {
          delete args[4]
        }
        if (args[5] === '') {
          delete args[5]
        }
      }

      /**
       * 属性值
       * 此处注意，如果没有值，则默认值为空字符串，这个默认值有什么作用呢？
       * 1）在解析props时，如果propsData中的某个属性的类型为Boolean，并且其值为空字符串，
       *  则设置其值为true，也就是说，可以省略Boolean类型的值，只在标签上设置属性，也表示为true。
       *  示例：
       *  one:{
       *    props:{
       *      test:Boolean
       *    }
       *  }
       *  two:{
       *    template:'<div><one test></one></div>'
       *  }
       *  则在one组件中，test属性的值为true。
       *  详情可查看core/util/props.js --> validateProp方法的源码和注释。
       */
      const value = args[3] || args[4] || args[5] || ''
      attrs[i] = {
        name: args[1], //属性名
        //属性值
        value: decodeAttr(
          value,
          //某些浏览器会在通过innerHTML获得html的字符串时，会对属性中存在的'\n'字符串进行编码为'&#10;'，
          //所以此时就需要对innerHTML获得的字符串进行解码，以将'&#10;'转换为'\n'。
          //详情可见：platforms/web/util/compat.js中对浏览器的该特性的检测结果。
          //解释：在书写html时，对于某些特殊字符，比如'<'，需要使用'&lt;'代替，但是在使用js处理该字符时，需要将'&lt;'转换
          //为'<'，所以在这里需要一次解码
          options.shouldDecodeNewlines
        )
      }
    }

    //如果当前元素存在结束标签
    if (!unary) {
      stack.push({tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs})
      lastTag = tagName
    }

    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

  /**
   * 该方法用于处理结束标签。所有的开始标签都会被放到一个栈中，结束标签会匹配栈中的
   * 开始标签，如果匹配成功，则会调用options.end方法进行处理。如果匹配失败，除非该标签为
   * br或者p标签，否则会忽略该标签。
   * 也就是说，开始标签不可以没有结束标签，但是结束标签没有开始标签时会被忽略。
   * @param tagName 标签名，如果未提供标签名，则会清空栈
   * @param start
   * @param end
   */
  function parseEndTag(tagName, start, end) {
    let pos, lowerCasedTagName
    //@todo 什么情况下start和end会为null
    //start和end在web端渲染的时候不会被使用，可能在服务器端渲染时会使用。
    if (start == null) start = index
    if (end == null) end = index

    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
    }

    // Find the closest opened tag of the same type
    if (tagName) {
      for (pos = stack.length - 1; pos >= 0; pos--) {
        //因为html是大小写不敏感的，所以，都转换成小写进行比较。
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      // If no tag name is provided, clean shop
      //如果未提供标签名，则直接清空栈
      pos = 0
    }

    if (pos >= 0) { //说明在栈中找到了与当前结束标签匹配的开始标签
      // Close all the open elements, up the stack
      for (let i = stack.length - 1; i >= pos; i--) {
        if (process.env.NODE_ENV !== 'production' &&
          (i > pos || !tagName) && //如果html的开始标签和结束标签匹配正确，则i应该一开始就等于pos，也就是说目标标签就在栈顶
          options.warn
        ) {
          options.warn(
            `tag <${stack[i].tag}> has no matching end tag.`
          )
        }
        if (options.end) {
          //对于options的end方法，不会使用任何参数
          options.end(stack[i].tag, start, end)
        }
      }

      // Remove the open elements from the stack
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    } else if (lowerCasedTagName === 'br') { //在栈中未找到相应的标签
      //对于</br>标签，创建一个单标签即可
      if (options.start) {
        //对于options的start方法，不会使用start和end参数
        options.start(tagName, [], true, start, end) //第三个参数为true，说明不需要将当前元素放入栈中。
      }
    } else if (lowerCasedTagName === 'p') {
      //如果结束标签是p标签，并且未找到相应的开始标签，则创建一个空的p元素，
      //比如原始的模板字符串为：'<div></p></div>'，最后的处理结果为：'<div><p></p></div>'
      if (options.start) {
        options.start(tagName, [], false, start, end)
      }
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
    //其他情况下，单独出现的结束标签不做处理，直接忽略。
  }
}
